[Firebase][iOS] Firebase Cloud Firestore でプロフィール機能を作ってみよう
はじめに
モバイルアプリサービス部の中安です。
「Firebase
を触ってみるシリーズ」の続きになります。
前回はAuthentication で会員機能を作ってみようというお題で書かせていただきましたが、
今回はFirebase
のデータベースサービスFirebase Cloud Firestore
でプロフィール機能を作ってみようと思います。
またもウダウダと書きますが、何かのお役に立てば幸いです。
準備
さて、この記事は
Firebase
プロジェクトの作成は終わっているXcode
側ではFirebaseSDK
の組み込みなどが終わっている
という前提で書いていきます。
このあたりがまだという方は、ドキュメントを参照してくださいませ。
また、認証したユーザと絡めてデータベースを扱うので、冒頭で紹介した前回の記事で作った認証機能がすでにアプリに組み込まれていることも前提にしています。
Xcodeプロジェクト側
Cocoapods
Podfile
に Firebase Cloud Firestore
用のライブラリを含めて、ターミナルで pod install
します
pod 'Firebase/Firestore'
Firebaseプロジェクト側
まだ未使用の場合はFirebase
のメニューからDatabase
を選ぶと、Cloud Firestore
のウェルカム画面が表示されます。
初期準備としてはその中のデータベースの作成
ボタンを押して次へ進みます。
途中で出てくるダイアログはとりあえずそのままOKでいいと思います。
※旧版のデータベースであるRealtime Database
をすでにFirebase
プロジェクトで使用している場合は、
Cloud Firestore
へのデータ移行などが必要になるとのことで注意してください。詳しくはドキュメントを参照ください。
そもそも Cloud Firestore はどんなDB
さて、手を動かしていく前に「そもそもCloud Firestore
とはどんなデータベースなんだ」ということを復習しておきましょう。
ドキュメントには以下のように書かれています。
Cloud Firestore は NoSQL ドキュメント指向データベースです。SQL データベースとは違い、テーブルや行はありません。代わりに、データは「ドキュメント」に格納し、それが「コレクション」にまとめられます。
各「ドキュメント」には、一連のキーと値のペアが含まれています。Cloud Firestore は、小型のドキュメントの大きなコレクションを格納するために最適化されています。
すべてのドキュメントはコレクションに保存する必要があります。ドキュメントには、「サブコレクション」と、ネストされたオブジェクトを格納できます。このどちらにも、文字列などの基本フィールドや、リストなどの複雑なオブジェクトを含めることができます。
もしもリレーショナルデータベース(RDB)に頭が慣れていると少し混乱してしまいそうなのですが、 平たくいうと「クラウドサービス上に大きなデータ保存用のJSONファイルがある」というイメージを持つとわかりやすくなるかもしれません。
JSONファイルの中の細かな住所を指定して、その中の値を加えたり書き換えたり消したりするというようなイメージですね (厳密には違うかもですが、自分はそれでしっくりきました)。
上記の引用にも既に言及されてはいますが、
Firestore
の世界ではいくつかの覚えておくべき用語があります。
ドキュメント
イメージでいうと「レコード」に近いかもしれません。
しかし、一般的なRDBの世界ではレコードはそのスキーマ(どういうカラムがあるか)が最初に決まっているのに対して、Firestore
はスキーマレスなデータベースなので、1つのドキュメント内のデータはそれぞれ自由なキーと値が入ることになります。
このあたりもJSONをイメージして考えると難しくはないかもしれませんね。
コレクション
イメージでいうと「テーブル」に近いかもしれません。
コレクションは複数のドキュメントの入れ物にあたり、ドキュメントは必ずどこかのコレクションに含まれています。 また、ドキュメントの中に更にコレクションを入れることも可能で「サブコレクション」と呼ばれるそうです。
ルール
「セキュリティルール」ともいいます。 コレクション内のドキュメントへのアクセス制限などを詳細かつ直感的に指定することができます。
REST API
のインターフェイスを作るような感覚(ノリ的にはSwagger的な…いや、違うか)で、「どのコレクションの」「どのドキュメントに」「誰が」「どういう条件で」「何ができる」といったことをプログラマブルに書ける点が良いです。
コンソール画面ではエディタに加えてシンタックスチェックもしてくれたり、ルールのシミュレーション機能もあるので 構築の段階で躓く時間は短くなるのではと思います。
ルールについては後ほどにも書くのですが、さらなる詳細はドキュメントを参照くださいませ。
今回のサンプルアプリとその準備
今回は題名の通り「プロフィール機能のあるアプリ」を作ってみます。
前回の認証機能で作成したログインユーザには「名前」や「メールアドレス」くらいしか有益な情報を持っていませんので、 プロフィール画面を使ってユーザに属性を追加するイメージになります。
アプリ側
UI
プロフィール画面は下図のような見た目にしました。
(プロフィールというよりアンケートのような項目になってしまいましたが・・・)
簡単な論理名や仕様は図の矢印で示したとおりです。
ビューコントローラ
ビューコントローラはProfileViewController
として以下のように用意します。
class ProfileViewController: UIViewController { // 好きな食事 @IBOutlet private weak var favoriteMealSegment: UISegmentedControl! // 好きなスポーツ @IBOutlet private weak var favoriteSportsSegment: UISegmentedControl! // 飼ってるペット @IBOutlet private weak var yourPetSegment: UISegmentedControl! // 毎日朝食を食べる (yes or no) @IBOutlet private weak var breakfastEverdaySwitch: UISwitch! // 登録ボタン押下時 @IBAction private func didTapRegisterButton() { // あとで実装 } }
その他
以下のような列挙型(enum)も用意しておきます。
enum FavoriteMeal: String { case japanese, weastern, chinese, italian static let items: [FavoriteMeal] = [.japanese, .weastern, .chinese, .italian] } enum FavoriteSports: String { case baseball, soccer, tennis static let items: [FavoriteSports] = [.baseball, .soccer, .tennis] } enum YourPet: String { case dog, cat static let items: [YourPet] = [.dog, .cat] }
見たままなので説明は割愛です。
Firestore側
Firestore
のコンソール画面からルール
タブを開きます。
この画面ではルールの編集ができます。
今回は各ユーザのプロフィール情報を格納する場所としてusers
というコレクションを使うものとし、それに対するルールを設定していきます。
ルールの概要は
- 認証機能でサインインしたユーザが自分のプロフィール情報のみを触ることができる
users
コレクションのドキュメントの識別子はユーザのIDとする
にしたいと思うので、ここを下記のように編集します。
service cloud.firestore { match /databases/{database}/documents { match /users/{userID} { allow create, update: if request.auth.uid == userID } } }
基本的な文法
このルール設定は下記の予約語を基本的には使うことになります。
match
ステートメント ・・・ 適用するドキュメントの場所を指します。WebAPIのパスを設定するような感じですね。allow
式 ・・・ 指定の場所に対して許可するアクセス権限とその条件です。
match /users/{userID}
としているのは、
users
というコレクションの中の{userID}
という動的なドキュメント識別子に対してのルールであることを示しています。
条件指定
allow
式の中の:
の右側を見てみてください。ここでは条件指定をしています。
match
ステートメントで書かれている{userID}
は、任意の変数のようなものです。それを条件文で使用することができます。
request.auth.uid
は認証されたユーザのIDを指すので、
このIf文では「認証したユーザのIDと等しい識別子のドキュメント」という条件指定をしているわけです。
アクセス権限
allow
式の中の:
の左側はアクセスのパーミッションを表します。
基本的なCRUDであるcreate
、read
、update
、delete
の権限を指定できます。
この例では「認証したユーザは自分のIDと等しい識別子のドキュメントに追加と更新のパーミッションがある」というusers
へのルールができあがったことになります。
データ保存の実装
それでは、準備で作っていた「登録ボタン押下時」のハンドラメソッドに、Firestore
へのデータ保存機能を実装していきます。
@IBAction private func didTapRegisterButton() { guard let user = Auth.auth().currentUser else { // サインインしていない場合の処理をするなど return } let favoriteMeal = FavoriteMeal.items[favoriteMealSegment.selectedSegmentIndex].rawValue let favoriteSports = FavoriteSports.items[favoriteSportsSegment.selectedSegmentIndex].rawValue let yourPet = YourPet.items[yourPetSegment.selectedSegmentIndex].rawValue let breakfastEverday = breakfastEverdaySwitch.isOn let db = Firestore.firestore() db.collection("users").document(user.uid).setData([ "favoriteMeal": favoriteMeal, "favoriteSports": favoriteSports, "yourPet": yourPet, "breakfastEverday": breakfastEverday, ]) { error in if let error = error { // エラー処理 return } // 成功したときの処理 } }
実装としてはざっとこんな感じです。
本来であればFirestore
の処理はビューコントローラからは切り分けたいところですが、
サンプルなのでベタ書きしています。ご了承を。
ログインしているユーザを取得する
ここまでに何度か書いていますが、 この記事では既に前回の記事で作った認証機能がアプリには備わっているという前提にして話を進めています。
一番最初のAuth.auth().currentUser
でログインユーザを取得しておきます。
ここでnil
が返ってくるということはログインしているユーザがいないということなので、
ここから先には進ませないようにしています。
項目の値を取得する
ここは各アプリによって様々だと思うので、深くは書きません。 このサンプルでは列挙体の値はそのままDBに渡すことにします。
ここでは文字列とブール値を取っていますが、整数や配列などの値も渡すことが可能です。 扱えるデータ型についての詳細はドキュメントを参照ください
Firestoreオブジェクトを取得
Firestore
オブジェクトはFirestore
クラスのシングルトンとして用意されているのでそれを呼び出します。
let db = Firestore.firestore()
データのセット
コレクション
Firestore
オブジェクトはcollection(_:)
を使って、コレクションが抽象化された参照オブジェクトを取得できます。
今回はusers
コレクションに対してアクセスをしたいので下記のような指定をしています。
db.collection("users")
ドキュメント
先述のルールの項でも書いたように今回はユーザIDをドキュメントの識別子として扱うルールに設定しました。
コレクションの参照オブジェクトに対してdocument(_:)
メソッドを使うことで
ドキュメントを抽象化した参照オブジェクトを取得することができます。
db.collection("users").document(user.uid)
データのセット
ドキュメント参照オブジェクトのsetData()
メソッドを使用することで
Firestore
データベースに実際にデータを渡すことができます。
引数に[String : Any]
型のディクショナリで以下のように任意のデータ構造を渡すことができ、
その完了時にはエラーがあったかどうかのコールバックを受け取ることができます。
db.collection("users").document(user.uid).setData([ "favoriteMeal": favoriteMeal, "favoriteSports": favoriteSports, "yourPet": yourPet, "breakfastEverday": breakfastEverday, ]) { error in if let error = error { // エラー処理 return } // 成功したときの処理 }
動作確認
では、実際にアプリを起動させて、各項目を色々と動かした後に登録ボタンを押してみましょう。
コンソールを見てみるとデータが送られ、保存されていることが確認できました。
さらに、このコンソール画面を見ながらアプリで各項目を色々と変更してから再び登録ボタンを押してみると、 リアルタイムにコンソール画面の値が更新されるのも確認できると思います。 (更新された値がオレンジ色に光って楽しいっ)
また、試しに別のアカウントでログインして同じことをしてみると、
今度は別のドキュメントがusers
に新規作成されのも確認できると思います。
データについて
スキーマレスだから…
ここで面白いのは、Firestore
のコンソール画面でセキュリティルールこそ設定したものの、
実際にデータのコンソールにusers
というコレクションを足したわけでもないのにデータの送信と保存がされたということです。
ドキュメントの中身もアプリ側で指定した通りに保存されています。 スキーマに合わせたSQL文を書くなどして挿入・更新をするRDB脳でいるとビックリしますね。
これがスキーマレスというわけです。
ルールがあるから…
setData()
により、
users
コレクションにユーザIDが識別子になっているドキュメントが存在しない場合は、ドキュメントが自動的に新規作成されます。
また、存在する場合はそのドキュメントを自動で更新してくれます。便利ですね。
先ほど設定したルールでallow create update
と指定しているので、このような動きをしてくれるわけです。
試しにupdate
をルール記述から消してからアプリを動かしてみてください。
ドキュメントの新規作成はしてくれますが、更新しようとするとコールバック時にパーミッションエラーが返されるはずです。
データ取得の実装
さて、登録したプロフィールがデータベースに保存されたので、今度はそれを取得する動作を実装しようと思います。
ルールの変更
先ほどはcreate
とupdate
のパーミッションをルールとして与えていました。
では、取得するときはどうでしょうか。
プロフィールは「自分にしか見えない情報」というよりは「他のユーザに見せる情報」とも言える気がします。 なので、データの追加や更新とは異なり、他ユーザにもリソースの取得権限は与えたほうが良さそうです。
service cloud.firestore { match /databases/{database}/documents { match /users/{userID} { allow create, update: if request.auth.uid == userID allow read: if request.auth != null } } }
「認証したユーザであれば他のユーザのプロフィールが見れる」という仕様にするならば、
read
パーミッションはこのように設定することになると思います。
アプリ側の実装
画面が表示されるときに、前回登録した内容が初期値として表示されていてほしいところです。
今回のサンプルではviewWillAppear()
のタイミングで自身のプロフィールを取得し、
各ビュー部品にバインドしていくことにします。
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) guard let user = Auth.auth().currentUser else { return } let db = Firestore.firestore() db.collection("users").document(user.uid).getDocument() { [weak self] snapshot, error in guard let self = self else { return } if error != nil { // エラー処理 return } guard let snapshot = snapshot, snapshot.exists, let data = snapshot.data() else { // データがないときの処理 return } if let value = data["favoriteMeal"] as? String, let favoriteMeal = FavoriteMeal(rawValue: value), let index = FavoriteMeal.items.firstIndex(of: favoriteMeal) { self.favoriteMealSegment.selectedSegmentIndex = index } if let value = data["favoriteSports"] as? String, let favoriteSports = FavoriteSports(rawValue: value), let index = FavoriteSports.items.firstIndex(of: favoriteSports) { self.favoriteSportsSegment.selectedSegmentIndex = index } if let value = data["yourPet"] as? String, let yourPet = YourPet(rawValue: value), let index = YourPet.items.firstIndex(of: yourPet) { self.yourPetSegment.selectedSegmentIndex = index } if let value = data["breakfastEverday"] as? Bool { self.breakfastEverdaySwitch.isOn = value } } }
ドキュメント取得
ドキュメントの参照オブジェクトを取得するまでは保存時と大差ありません。
取得の際はそこにgetDocument()
メソッドを呼び出します。
すると、コールバックとして「ドキュメントのスナップショットオブジェクト」と「エラーオブジェクト」が渡されてきます。
スナップショットには指定したドキュメントの有無、存在した場合のドキュメントの内容が入っているので、それを使います。 データ保存時に渡したディクショナリと同形式でデータが取得ができるので、加工も簡単かと思います。
バインド
snapshot.data()
で取得したデータを各ビュー部品にバインドしていきます。
上記のサンプルソースはあまりキレイな組み方とは言えないですが、
これによりアプリの表示とデータベースの内容が同期したといえるでしょう。
実際はカスタムな構造体またはクラスを作って、 ディクショナリからデータオブジェクトを作成するほうがソースコード的にはスッキリすると思いますが、ここでは割愛します。
最後に
今回はFirestore
の基本的なところ、
CRUD
の中でもCRU
の部分だけをとりあげて、簡単なプロフィール画面を作成しました。
実務的に使うのであれば、ルールはもっと細分化したり、データもキレイに構造化したり、インデックスを使用したりするべきでしょうが、 まずはサービスの雰囲気がつかめるところまでをやってみました。
もっと突っ込んだところは別機会にアウトプットしようと思います。
詳しくは Firebase Cloud Firestore のリファレンスを参照ください